// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.googlesource.gerrit.plugins.github.oauth;

import static com.googlesource.gerrit.plugins.github.oauth.CanonicalWebUrls.trimTrailingSlash;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;

import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSortedMap;
import com.google.gerrit.extensions.client.AuthType;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.github.oauth.OAuthProtocol.Scope;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.SortedMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.eclipse.jgit.lib.Config;

@Singleton
public class GitHubOAuthConfig {
  private final Config config;

  public static final String CONF_SECTION = "github";
  public static final String CONF_KEY_SECTION = "github-key";
  public static final String GITHUB_OAUTH_AUTHORIZE = "/login/oauth/authorize";
  public static final String GITHUB_OAUTH_ACCESS_TOKEN = "/login/oauth/access_token";
  public static final String GERRIT_OAUTH_FINAL = "/oauth";
  public static final String GITHUB_URL_DEFAULT = "https://github.com";
  public static final String GITHUB_API_URL_DEFAULT = "https://api.github.com";
  public static final String GERRIT_LOGIN = "/login";
  public static final String GERRIT_LOGOUT = "/logout";
  public static final String GITHUB_PLUGIN_OAUTH_SCOPE = "/plugins/github-plugin/static/scope.html";
  public static final ScopeKey GITHUB_DEFAULT_SCOPES_KEY = new ScopeKey("scopes", null, 0);

  public final String gitHubUrl;
  public final String gitHubApiUrl;
  public final String gitHubClientId;
  public final String gitHubClientSecret;
  public final String logoutRedirectUrl;
  public final String httpHeader;
  public final String gitHubOAuthUrl;
  public final String gitHubOAuthAccessTokenUrl;
  public final String scopeSelectionUrl;
  public final boolean enabled;

  public final SortedMap<ScopeKey, List<OAuthProtocol.Scope>> scopes;
  public final Map<String, SortedMap<ScopeKey, List<OAuthProtocol.Scope>>> virtualScopes;

  public final int fileUpdateMaxRetryCount;
  public final int fileUpdateMaxRetryIntervalMsec;
  public final String oauthHttpHeader;

  public final long httpConnectionTimeout;
  public final long httpReadTimeout;
  private final Map<String, KeyConfig> keyConfigMap;
  private final KeyConfig currentKeyConfig;
  private final Optional<String> cookieDomain;

  @Inject
  protected GitHubOAuthConfig(@GerritServerConfig Config config) {
    this.config = config;

    httpHeader =
        Preconditions.checkNotNull(
            config.getString("auth", null, "httpHeader"),
            "HTTP Header for GitHub user must be provided");
    gitHubUrl =
        trimTrailingSlash(
            MoreObjects.firstNonNull(
                config.getString(CONF_SECTION, null, "url"), GITHUB_URL_DEFAULT));
    gitHubApiUrl =
        trimTrailingSlash(
            MoreObjects.firstNonNull(
                config.getString(CONF_SECTION, null, "apiUrl"), GITHUB_API_URL_DEFAULT));
    gitHubClientId =
        Preconditions.checkNotNull(
            config.getString(CONF_SECTION, null, "clientId"), "GitHub `clientId` must be provided");
    gitHubClientSecret =
        Preconditions.checkNotNull(
            config.getString(CONF_SECTION, null, "clientSecret"),
            "GitHub `clientSecret` must be provided");
    scopeSelectionUrl = config.getString(CONF_SECTION, null, "scopeSelectionUrl");

    oauthHttpHeader = config.getString("auth", null, "httpExternalIdHeader");
    gitHubOAuthUrl = gitHubUrl + GITHUB_OAUTH_AUTHORIZE;
    gitHubOAuthAccessTokenUrl = gitHubUrl + GITHUB_OAUTH_ACCESS_TOKEN;
    logoutRedirectUrl = config.getString(CONF_SECTION, null, "logoutRedirectUrl");

    enabled = config.getString("auth", null, "type").equalsIgnoreCase(AuthType.HTTP.toString());
    cookieDomain = Optional.ofNullable(config.getString("auth", null, "cookieDomain"));
    scopes = getScopes(config);
    virtualScopes = getVirtualScopes(config);

    fileUpdateMaxRetryCount = config.getInt(CONF_SECTION, "fileUpdateMaxRetryCount", 3);
    fileUpdateMaxRetryIntervalMsec =
        config.getInt(CONF_SECTION, "fileUpdateMaxRetryIntervalMsec", 3000);

    httpConnectionTimeout =
        TimeUnit.MILLISECONDS.convert(
            ConfigUtil.getTimeUnit(
                config, CONF_SECTION, null, "httpConnectionTimeout", 30, TimeUnit.SECONDS),
            TimeUnit.SECONDS);

    httpReadTimeout =
        TimeUnit.MILLISECONDS.convert(
            ConfigUtil.getTimeUnit(
                config, CONF_SECTION, null, "httpReadTimeout", 30, TimeUnit.SECONDS),
            TimeUnit.SECONDS);

    Map<String, KeyConfig> configuredKeyConfig =
        config.getSubsections(CONF_KEY_SECTION).stream()
            .map(KeyConfig::new)
            .collect(Collectors.toMap(KeyConfig::getKeyId, Function.identity()));
    keyConfigMap = configuredKeyConfig;
    List<KeyConfig> currentKeyConfigs =
        keyConfigMap.values().stream().filter(KeyConfig::isCurrent).collect(Collectors.toList());
    if (currentKeyConfigs.size() != 1) {
      throw new IllegalStateException(
          String.format(
              "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
              CONF_KEY_SECTION, currentKeyConfigs.size()));
    }
    currentKeyConfig = currentKeyConfigs.get(0);
  }

  private SortedMap<ScopeKey, List<Scope>> getScopes(Config config) {
    return getScopesInSection(config, null);
  }

  static Map<String, SortedMap<ScopeKey, List<Scope>>> getVirtualScopes(Config config) {
    return config.getSubsections(CONF_SECTION).stream()
        .collect(Collectors.toMap(k -> k, v -> getScopesInSection(config, v)));
  }

  private static SortedMap<ScopeKey, List<Scope>> getScopesInSection(
      Config config, String subsection) {
    return config.getNames(CONF_SECTION, subsection, true).stream()
        .filter(k -> k.startsWith("scopes"))
        .filter(k -> !k.endsWith("Description"))
        .filter(k -> !k.endsWith("Sequence"))
        .collect(
            ImmutableSortedMap.toImmutableSortedMap(
                Comparator.comparing(ScopeKey::sequence),
                k ->
                    new ScopeKey(
                        k,
                        config.getString(CONF_SECTION, subsection, k + "Description"),
                        config.getInt(CONF_SECTION, subsection, k + "Sequence", 0)),
                v -> parseScopesString(config.getString(CONF_SECTION, subsection, v))));
  }

  private static List<Scope> parseScopesString(String scopesString) {
    ArrayList<Scope> result = new ArrayList<>();
    if (Strings.emptyToNull(scopesString) != null) {
      String[] scopesStrings = scopesString.split(",");
      for (String scope : scopesStrings) {
        result.add(Enum.valueOf(Scope.class, scope.trim()));
      }
    }

    return result;
  }

  public Scope[] getDefaultScopes() {
    if (scopes == null || scopes.get(GITHUB_DEFAULT_SCOPES_KEY) == null) {
      return new Scope[0];
    }
    return scopes.get(GITHUB_DEFAULT_SCOPES_KEY).toArray(new Scope[0]);
  }

  public KeyConfig getCurrentKeyConfig() {
    return currentKeyConfig;
  }

  public KeyConfig getKeyConfig(String subsection) {
    return keyConfigMap.get(subsection);
  }

  public Optional<String> getCookieDomain() {
    return cookieDomain;
  }

  public class KeyConfig {

    public static final int PASSWORD_LENGTH_DEFAULT = 16;
    public static final String CIPHER_ALGORITHM_DEFAULT = "AES/ECB/PKCS5Padding";
    public static final String SECRET_KEY_ALGORITHM_DEFAULT = "AES";
    public static final boolean IS_CURRENT_DEFAULT = false;
    public static final String KEY_ID_DEFAULT = "current";

    public static final String PASSWORD_DEVICE_CONFIG_LABEL = "passwordDevice";
    public static final String PASSWORD_LENGTH_CONFIG_LABEL = "passwordLength";
    public static final String SECRET_KEY_CONFIG_LABEL = "secretKeyAlgorithm";
    public static final String CIPHER_ALGO_CONFIG_LABEL = "cipherAlgorithm";
    public static final String CURRENT_CONFIG_LABEL = "current";

    public static final String KEY_DELIMITER = ":";

    private final String passwordDevice;
    private final Integer passwordLength;
    private final String cipherAlgorithm;
    private final String secretKeyAlgorithm;
    private final String keyId;
    private final Boolean isCurrent;

    KeyConfig(String keyId) {

      if (keyId.contains(KEY_DELIMITER)) {
        throw new IllegalStateException(
            String.format(
                "Configuration error. %s.%s should not contain '%s'",
                CONF_KEY_SECTION, keyId, KEY_DELIMITER));
      }

      this.passwordDevice = trimTrailingSlash(getPasswordDeviceOrThrow(config, keyId));
      this.passwordLength =
          config.getInt(
              CONF_KEY_SECTION, keyId, PASSWORD_LENGTH_CONFIG_LABEL, PASSWORD_LENGTH_DEFAULT);
      isCurrent =
          config.getBoolean(CONF_KEY_SECTION, keyId, CURRENT_CONFIG_LABEL, IS_CURRENT_DEFAULT);

      this.cipherAlgorithm =
          MoreObjects.firstNonNull(
              config.getString(CONF_KEY_SECTION, keyId, CIPHER_ALGO_CONFIG_LABEL),
              CIPHER_ALGORITHM_DEFAULT);

      this.secretKeyAlgorithm =
          MoreObjects.firstNonNull(
              config.getString(CONF_KEY_SECTION, keyId, SECRET_KEY_CONFIG_LABEL),
              SECRET_KEY_ALGORITHM_DEFAULT);
      this.keyId = keyId;
    }

    public byte[] readPassword() throws IOException {
      Path devicePath = Paths.get(passwordDevice);
      try (FileInputStream in = new FileInputStream(devicePath.toFile())) {
        byte[] passphrase = new byte[passwordLength];
        if (in.read(passphrase) < passwordLength) {
          throw new IOException("End of password device has already been reached");
        }

        return passphrase;
      }
    }

    public String getCipherAlgorithm() {
      return cipherAlgorithm;
    }

    public String getSecretKeyAlgorithm() {
      return secretKeyAlgorithm;
    }

    public Boolean isCurrent() {
      return isCurrent;
    }

    public String getKeyId() {
      return keyId;
    }
  }

  /**
   * Method returns the password device value for a given {@code keyId}.
   *
   * @throws {@link IllegalStateException} when password device is not configured for {@code keyId}
   */
  private static String getPasswordDeviceOrThrow(Config config, String keyId) {
    String passwordDevice =
        config.getString(CONF_KEY_SECTION, keyId, KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL);
    if (Strings.isNullOrEmpty(passwordDevice)) {
      throw new IllegalStateException(
          String.format(
              "Configuration error. Missing %s.%s for key-id '%s'",
              CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, keyId));
    }

    return passwordDevice;
  }
}
